--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 4c8d17f4fbf0f51b2511a02e30b0dafbf1c7ab2e
Parents : 4322225
Author : Mark Qvist <bc7291552be7a58f361522990465165c>
Signature : T66BB85Valid, signed by author
Date : 2026-07-19T17:43:38+02:00
Tile prefetching
Changes
4 files changed, 185 insertions(+), 4 deletions(-)
Diff
diff --git a/sbapp/mapview/downloader.py b/sbapp/mapview/downloader.py
index a13ae537..e5c7ec78 100644
--- a/sbapp/mapview/downloader.py
+++ b/sbapp/mapview/downloader.py
@@ -5,6 +5,8 @@ __all__ = ["Downloader"]
import logging
import traceback
from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
+import os
+import tempfile
from os import environ, makedirs
from os.path import exists, join
from random import choice
@@ -86,6 +88,18 @@ class Downloader:
future = self.executor.submit(self._load_tile, tile)
self._futures.append(future)
+ def prefetch_tile(self, map_source, zoom, tile_x, tile_y):
+ future = self.executor.submit(self._prefetch_tile, map_source, zoom, tile_x, tile_y)
+ future._mapview_prefetch = True
+ self._futures.append(future)
+
+ def clear_prefetches(self):
+ count = 0
+ for f in self._futures:
+ if getattr(f, '_mapview_prefetch', False):
+ f.cancel()
+ count += 1
+
def download(self, url, callback, **kwargs):
future = self.executor.submit(self._download_url, url, callback, kwargs)
self._futures.append(future)
@@ -116,16 +130,53 @@ class Downloader:
if tile.map_source.quad_key: uri = tile.map_source.url.format(q=self.__to_quad(tile.tile_x,tile_y,tile.zoom), s=choice(tile.map_source.subdomains))
else: uri = tile.map_source.url.format(z=tile.zoom, x=tile.tile_x, y=tile_y, s=choice(tile.map_source.subdomains))
- # st = time() # TODO: Remove
+ st = time()
self.ensure_session()
- response = self.session.get(uri, headers={'User-agent': USER_AGENT}, timeout=10)
try:
- # RNS.log(f"Response ready in {RNS.prettyshorttime(time()-st)} for {uri}") # TODO: Remove
+ response = self.session.get(uri, headers={'User-agent': USER_AGENT}, timeout=10)
response.raise_for_status()
data = response.content
- with open(cache_fn, "wb") as fd: fd.write(data)
+ dir_name = os.path.dirname(cache_fn)
+ if not exists(dir_name): makedirs(dir_name)
+ fd, tmp_path = tempfile.mkstemp(dir=dir_name, prefix=".tile_")
+ try:
+ with os.fdopen(fd, "wb") as f: f.write(data)
+ os.replace(tmp_path, cache_fn)
+ except Exception:
+ try: os.unlink(tmp_path)
+ except: pass
+ raise
return tile.set_source, (cache_fn,)
except Exception as e: RNS.log(f"Error while downloading map tile: {e}", RNS.LOG_WARNING) if RNS.sl(RNS.LOG_DEBUG) else None
+ # finally: RNS.log(f"Got tile in {RNS.prettyshorttime(time()-st)}: {uri}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
+
+ def _prefetch_tile(self, map_source, zoom, tile_x, tile_y):
+ fn = map_source.cache_fmt.format(image_ext=map_source.image_ext, cache_key=map_source.cache_key,
+ zoom=zoom, tile_x=tile_x, tile_y=tile_y)
+ cache_fn = join(map_source.cache_dir, fn)
+ if exists(cache_fn): return None
+
+ tile_y_tms = map_source.get_row_count(zoom) - tile_y - 1
+ if map_source.quad_key: uri = map_source.url.format(q=self.__to_quad(tile_x, tile_y_tms, zoom), s=choice(map_source.subdomains))
+ else: uri = map_source.url.format(z=zoom, x=tile_x, y=tile_y_tms, s=choice(map_source.subdomains))
+
+ self.ensure_session()
+ try:
+ response = self.session.get(uri, headers={'User-agent': USER_AGENT}, timeout=10)
+ response.raise_for_status()
+ data = response.content
+ dir_name = os.path.dirname(cache_fn)
+ if not exists(dir_name): makedirs(dir_name)
+ fd, tmp_path = tempfile.mkstemp(dir=dir_name, prefix=".tile_")
+ try:
+ with os.fdopen(fd, "wb") as f: f.write(data)
+ os.replace(tmp_path, cache_fn)
+ except Exception:
+ try: os.unlink(tmp_path)
+ except: pass
+ raise
+ except Exception as e: RNS.log(f"Error while prefetching map tile: {e}", RNS.LOG_WARNING) if RNS.sl(RNS.LOG_DEBUG) else None
+ return None
def _check_executor(self, dt):
start = time()
diff --git a/sbapp/mapview/mbtsource.py b/sbapp/mapview/mbtsource.py
index 319f10ae..afb770df 100644
--- a/sbapp/mapview/mbtsource.py
+++ b/sbapp/mapview/mbtsource.py
@@ -90,3 +90,7 @@ class MBTilesMapSource(MapSource):
def get_lat(self, zoom, y):
if self.is_xy: return y
return super().get_lat(zoom, y)
+
+ def prefetch_tile(self, zoom, tile_x, tile_y):
+ # No-op for local MBTiles sources
+ pass
diff --git a/sbapp/mapview/source.py b/sbapp/mapview/source.py
index 2a68d02c..0f29b560 100644
--- a/sbapp/mapview/source.py
+++ b/sbapp/mapview/source.py
@@ -1,5 +1,6 @@
__all__ = ["MapSource"]
+import RNS
import hashlib
from math import atan, ceil, cos, exp, log, pi, tan
@@ -105,3 +106,9 @@ class MapSource:
def fill_tile(self, tile):
if tile.state == "done": return
Downloader.instance(cache_dir=self.cache_dir).download_tile(tile)
+
+ def clear_prefetches(self):
+ Downloader.instance(cache_dir=self.cache_dir).clear_prefetches()
+
+ def prefetch_tile(self, zoom, tile_x, tile_y):
+ Downloader.instance(cache_dir=self.cache_dir).prefetch_tile(self, zoom, tile_x, tile_y)
diff --git a/sbapp/mapview/view.py b/sbapp/mapview/view.py
index d2cee7c0..0ce6d78d 100644
--- a/sbapp/mapview/view.py
+++ b/sbapp/mapview/view.py
@@ -1,6 +1,7 @@
__all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", "MarkerMapLayer"]
import RNS
+import time
import webbrowser
from itertools import takewhile
from math import ceil, cos, log2, pi, sin, sqrt
@@ -341,6 +342,7 @@ class MapView(Widget):
_disabled_count = 0
high_res = True
high_res_mode = 1
+ log_prefetch = False
__events__ = ["on_map_relocated"]
@@ -945,6 +947,8 @@ class MapView(Widget):
else:
self.load_visible_tiles()
+ if self.tile_prefetching: self.prefetch_tiles()
+
def bbox_for_zoom(self, vx, vy, w, h, zoom):
# return a tile-bbox for the zoom
map_source = self.map_source
@@ -968,6 +972,30 @@ class MapView(Widget):
y_count = tile_y_last - tile_y_first
return (tile_x_first, tile_y_first, tile_x_last, tile_y_last, x_count, y_count)
+ def get_tile_range_for_zoom(self, zoom):
+ """Return the tile index rectangle (tx1, ty1, tx2, ty2) covering the
+ current viewport at the given zoom level. Coordinates are clamped to
+ the source bounds and tx2/ty2 are exclusive."""
+ map_source = self.map_source
+ size = map_source.dp_tile_size
+ max_x = map_source.get_col_count(zoom)
+ max_y = map_source.get_row_count(zoom)
+
+ # Geographic bounds of the current viewport
+ c_bl = self.get_latlon_at(0, 0)
+ c_tr = self.get_latlon_at(self.width, self.height)
+
+ x1 = map_source.get_x(zoom, c_bl.lon) / size
+ x2 = map_source.get_x(zoom, c_tr.lon) / size
+ y1 = map_source.get_y(zoom, c_bl.lat) / size
+ y2 = map_source.get_y(zoom, c_tr.lat) / size
+
+ tx1 = int(clamp(min(x1, x2), 0, max_x))
+ tx2 = int(clamp(max(x1, x2) + 1, 0, max_x))
+ ty1 = int(clamp(min(y1, y2), 0, max_y))
+ ty2 = int(clamp(max(y1, y2) + 1, 0, max_y))
+ return (tx1, ty1, tx2, ty2)
+
def load_visible_tiles(self):
map_source = self.map_source
vx, vy = self.viewport_pos
@@ -1050,6 +1078,97 @@ class MapView(Widget):
self.canvas_map.add(tile)
self._tiles.append(tile)
+ def prefetch_tiles(self):
+ if not self.tile_prefetching or self._pause: return
+ map_source = self.map_source
+ zoom = self._zoom
+
+ # Only prefetch when the visible tile set has changed
+ tx1, ty1, tx2, ty2 = self.get_tile_range_for_zoom(zoom)
+ state = (zoom, tx1, ty1, tx2, ty2)
+ if getattr(self, '_last_prefetch_state', None) == state: return
+ self._last_prefetch_state = state
+ self._last_prefetch_dispatch = time.time()
+
+ def trigger(dt):
+ self.prefetch_zoom_out_tiles()
+ self.prefetch_ring_tiles(rings=1)
+ self.prefetch_zoom_in_tiles(margin=0.5)
+
+ event = getattr(self, "_prefetch_event", None)
+ if event:
+ Clock.unschedule(event)
+ map_source.clear_prefetches()
+ if self.log_prefetch: RNS.log("Holding prefetch", RNS.LOG_DEBUG)
+ self._prefetch_event = Clock.schedule_once(trigger, 0.7)
+
+ def prefetch_zoom_out_tiles(self):
+ zoom = self._zoom - 1
+ map_source = self.map_source
+ if zoom < map_source.get_min_zoom(): return
+ tx1, ty1, tx2, ty2 = self.get_tile_range_for_zoom(zoom)
+ pf_count = 0
+ for x in range(tx1, tx2):
+ for y in range(ty1, ty2):
+ map_source.prefetch_tile(zoom, x, y)
+ pf_count += 1
+
+ if self.log_prefetch and pf_count: RNS.log(f"Dispatched zoom-out prefetch of {pf_count} tiles")
+
+ def prefetch_ring_tiles(self, rings=1):
+ zoom = self._zoom
+ map_source = self.map_source
+ tx1, ty1, tx2, ty2 = self.get_tile_range_for_zoom(zoom)
+ max_x = map_source.get_col_count(zoom)
+ max_y = map_source.get_row_count(zoom)
+ pf_count = 0
+ for ring in range(1, rings + 1):
+ outer_tx1 = tx1 - ring
+ outer_ty1 = ty1 - ring
+ outer_tx2 = tx2 + ring
+ outer_ty2 = ty2 + ring
+ # Top and bottom edges
+ for x in range(max(0, outer_tx1), min(max_x, outer_tx2)):
+ if outer_ty1 >= 0:
+ map_source.prefetch_tile(zoom, x, outer_ty1)
+ pf_count += 1
+ if outer_ty2 - 1 < max_y:
+ map_source.prefetch_tile(zoom, x, outer_ty2 - 1)
+ pf_count += 1
+ # Left and right edges (excluding corners already covered)
+ for y in range(max(0, outer_ty1 + 1), min(max_y, outer_ty2 - 1)):
+ if outer_tx1 >= 0:
+ map_source.prefetch_tile(zoom, outer_tx1, y)
+ pf_count += 1
+ if outer_tx2 - 1 < max_x:
+ map_source.prefetch_tile(zoom, outer_tx2 - 1, y)
+ pf_count += 1
+
+ if self.log_prefetch and pf_count: RNS.log(f"Dispatched ring prefetch of {pf_count} tiles")
+
+ def prefetch_zoom_in_tiles(self, margin=0.5):
+ zoom = self._zoom + 1
+ map_source = self.map_source
+ if zoom > map_source.get_max_zoom(): return
+ tx1, ty1, tx2, ty2 = self.get_tile_range_for_zoom(zoom)
+ width = tx2 - tx1
+ height = ty2 - ty1
+ dx = int(width * (1.0 - margin) / 2.0)
+ dy = int(height * (1.0 - margin) / 2.0)
+ ctx1 = tx1 + dx
+ cty1 = ty1 + dy
+ ctx2 = tx2 - dx
+ cty2 = ty2 - dy
+ if ctx2 <= ctx1: ctx2 = ctx1 + 1
+ if cty2 <= cty1: cty2 = cty1 + 1
+ pf_count = 0
+ for x in range(ctx1, ctx2):
+ for y in range(cty1, cty2):
+ map_source.prefetch_tile(zoom, x, y)
+ pf_count += 1
+
+ if self.log_prefetch and pf_count: RNS.log(f"Dispatched zoom-in prefetch of {pf_count} tiles")
+
def move_tiles_to_background(self):
# remove all the tiles of the main map to the background map
# retain only the one who are on the current zoom level
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────